Skip to content

feat(compare): side-by-side YAML diff for two resources#754

Merged
nadaverell merged 9 commits into
mainfrom
feature/compare-resources
May 23, 2026
Merged

feat(compare): side-by-side YAML diff for two resources#754
nadaverell merged 9 commits into
mainfrom
feature/compare-resources

Conversation

@nadaverell
Copy link
Copy Markdown
Contributor

@nadaverell nadaverell commented May 20, 2026

Summary

Adds a Compare ⇄ flow for diffing two Kubernetes resources of the same kind side-by-side. Modelled after Aptakube's resource-diff feature, scoped to v1: single cluster, same kind, two-way.

Two entry points converge on the same diff view:

  • Drawer: a Compare button in the resource action bar opens a picker dialog. Same-namespace candidates are promoted to the top (the obvious target), alphabetical within each group, ↑↓ keyboard navigation, Enter to pick.
  • Table compare mode: a Compare ⇄ toggle in the ResourcesView header flips the table into pick mode — leading A/B-badge column, sticky bottom tray with two pick slots, cap-at-2 with replace-oldest so a row click always has a visible effect, Esc exits.

Both routes navigate to /compare?kind=&apiGroup=&a=ns/name&b=ns/name (URL is shareable; apiGroup only needed for CRDs that collide with core kinds).

What the diff view does

Monaco DiffEditor (real implementation of the existing YamlDiffEditor stub at YamlEditor.tsx:250). Four header toggles: Raw metadata (off — Radar strips noise; flip on to see it), Spec only (drop status), Diff only (collapse unchanged regions), Unified (single-column layout). A↔B swap rewrites the URL; click the pencil on either pill to re-pick.

Resources are normalised before diffing — managedFields, uid, resourceVersion, creationTimestamp, kubectl.kubernetes.io/last-applied-configuration, pod-template-hash and similar noise stripped — so the diff shows intent, not server-assigned state.

Per-side rendering: a fast side renders immediately while the slow side spins; if A succeeds and B 404s (stale share-link), the working side stays useful, the failed side gets a red pill + warning icon, and the banner names exactly which side(s) failed.

Implementation notes

  • All shared logic lives in packages/k8s-ui/src/components/compare/. Pure helpers (parseRef/refToParam, togglePick/pickIndex, sortCandidates/filterCandidates, normalizeForCompare) are exported and unit-tested — 54 new tests pin the load-bearing behaviour.
  • One type for resource refs (CompareResourceRef), one CompareSide = 'a' | 'b', one SIDE_TONES palette for the A/B colors shared across drawer pill / picker chip / tray pill / table row badge.
  • The frontend useResources hook now gates on Boolean(kind) so the picker's lazy-on-open pattern doesn't fire a 404 for the empty kind.
  • Compare mode is gated on the host wiring onNavigate — embeds that don't pass it get the button hidden rather than a dead click.

Out of scope (deliberate)

Cross-kind compare and three-way compare — both worth more thought before adding. Cross-cluster compare needs Radar Hub multi-cluster anyway. Semantic ("by category") grouping over the raw line diff is the natural next step.

Adds a `Compare ⇄` flow for diffing two K8s resources of the same kind.
Two entry points converge on the same view:

- **Drawer "Compare" button**: opens a picker (same-namespace promoted,
  alphabetical, ↑↓/Enter keyboard nav), navigates to `/compare?kind=&a=&b=`.
- **Table compare mode**: header toggle flips ResourcesView into pick
  mode — leading A/B badge column, sticky bottom tray with 2 slots,
  cap-at-2 with replace-oldest, Esc exits.

Diff view: Monaco DiffEditor (real impl of the existing `YamlDiffEditor`
stub), side-by-side or unified, hide-unchanged collapses regions, Spec-only
drops status. Per-side error rendering — failed side gets a red pill +
banner, working side still renders. Swap A↔B updates URL. Resources are
normalized before diffing (strip managedFields/uid/resourceVersion/last-
applied/pod-template-hash) so the diff is signal not noise.

Pure helpers extracted with tests: `parseRef`/`refToParam` (URL ref
parsing), `togglePick`/`pickIndex` (cap-replace state machine),
`sortCandidates`/`filterCandidates` (picker order), `normalizeForCompare`.
49 new tests pin the load-bearing behavior.

The frontend `useResources` hook now gates on `Boolean(kind)` so the
picker's lazy-on-open pattern doesn't fire a 404 for the empty kind.
@nadaverell nadaverell requested a review from hisco as a code owner May 20, 2026 14:54
Comment thread web/src/components/workload/WorkloadView.tsx
Comment thread packages/k8s-ui/src/components/compare/CompareResourcePicker.tsx Outdated
Comment thread web/src/components/compare/CompareViewRoute.tsx
Address findings from the second review pass:

- parseRef now rejects `?a=prod/` (empty name after slash) — was silently
  wedging callers in an indefinite loading state since useResource had
  nothing to fetch.
- CompareViewRoute no longer flashes "Failed to load side A" for a refetch
  failure that has cached data — banner now only fires when the side has
  no data at all. Stale beats misleading.
- Kind change in compare mode now also exits compare mode, not just clears
  picks — leaving the tray on with empty pills after the kind switch was
  the worst-of-both UX.
- Tray render now gated on `compareEnabled` (mirrors the existing toolbar
  toggle gate) so library consumers without onNavigate can't get a tray
  whose Compare CTA silently no-ops.
- Picker error prop typed `unknown` (was `Error | null`) — React Query
  emits unknown; renderer falls back through `String(err)` for non-Error
  throws.
- togglePick cap-replace rewritten as `[...picks.slice(1), ref]` — clearer
  intent than the old slice arithmetic that only happened to be correct
  for cap=2.

Type cleanup:
- Single `NamespacedRef` shape replaces the three accidental duplicates
  (Pick / CompareTrayPick / ParsedRef / SortableCandidate).
- `SIDE_TONES` const centralises the A/B palette used by the drawer pill,
  picker chip, tray pill, and table row badge. Palette changes touch
  one place instead of four.
- rowHighlightClass extracted from a 4-deep nested ternary in
  ResourceRowCells (CLAUDE.md flag).

Web cleanup:
- useCompareCandidates lifts the shared `useResources` + map pattern out
  of useCompareLauncher and CompareViewRoute.

Comments stripped (CLAUDE.md: no WHAT narration): A→B gradient ribbon,
"see the design memo" pointer, DNS-1123 duplication in url.test.ts,
verbose PINNING block in normalize.test.ts.

51 tests pass (+2 — empty-name URL rejection, slash-only URL rejection).
Two viewport-related issues caught on real-world wide screens (2000px+)
that I missed in 1200px visual tests:

- ResourceCompareView's root lacked `flex-1`, so on wide viewports the
  diff view collapsed to its content width and left half the screen empty.
- CompareTray's Compare CTA and Exit X collided with the fixed-position
  debug + shortcut-help overlay buttons anchored bottom-right of the
  viewport. Added right padding to the tray content row so the buttons
  sit clear of the overlay.

Verified at 2000x1100.
Playwright MCP defaults to ~1280px, which hides whole classes of layout
bugs that only show up at desktop / ultrawide widths. The compare PR
shipped two of them — a full-screen view that collapsed to content width
without `flex-1`, and a sticky bar that collided with Radar's fixed
bottom-right overlay buttons — both invisible at 1280 but obvious at
2000+.

- /visual-test command now opens with a "set viewport FIRST" step, defaults
  to 1920x1080, and points at the 1280/1920/2560 sweep for layout-sensitive
  changes.
- visual-test-start.sh prints the same reminder on launch so anyone driving
  the harness sees it before navigating.
- "What to look for" checklist gains two wide-viewport bullets.
Comment thread packages/k8s-ui/src/components/compare/CompareResourcePicker.tsx
- New "Raw metadata" toggle (off by default) — when on, normalize skips
  the metadata-noise strip pass, so resourceVersion, uid, managedFields,
  last-applied-configuration, pod-template-hash, etc. show up in the diff.
  For the rare case of debugging API-level differences.
- Fixed the layout toggle to match the other three toolbar toggles:
  constant "Unified" label, highlight when active. Was changing both label
  and icon on click — inconsistent with Spec only / Diff only / Raw metadata.
- README + docs screenshot updated to reflect the four-toggle toolbar.
Comment thread web/src/components/compare/CompareViewRoute.tsx
Comment thread packages/k8s-ui/src/components/compare/CompareResourcePicker.tsx
…tate

Five fixes from the Bugbot review on commit 54d8789:

- WorkloadView's compare launcher now prefers the URL-supplied `rest.group`
  over the apiVersion-derived one. Otherwise CRD compare clicked before
  the resource fetch completes (or after a fetch failure) loses the group
  and routes to the wrong kind on collisions.
- CompareResourcePicker now takes a `sourceSide` prop and renders the
  source chip with the matching SIDE_TONES color/letter. Clicking the
  pencil on the B pill no longer shows "A" for the current B resource;
  the header copy also switches to "Replace side B with another …".
- Picker resets `query` and `highlightIdx` when `open` transitions to
  true. The drawer flow keeps the picker mounted, so without this the
  previous session's search and highlight position leaked into the next
  compare.
- Picker now also clamps `highlightIdx` on query change (was only on
  filtered.length change). Typing in search could leave Enter selecting
  a row different from the visually highlighted one.
- Picker arrow keys no-op when the filtered list is empty. Previously
  ArrowDown on empty list set highlightIdx to -1.

The Bugbot "empty name passes URL validation" finding was already fixed
in commit 9c9e089 (parseRef rejects `?a=prod/`); the comment was stale.
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 5805484. Configure here.

Comment thread web/src/components/compare/CompareViewRoute.tsx Outdated
Comment thread web/src/components/compare/CompareViewRoute.tsx Outdated
Comment thread packages/k8s-ui/src/components/compare/ResourceCompareView.tsx Outdated
Three fixes from Cursor Bugbot on commit 5805484:

- CRD compare was silently broken: App.tsx's URL-sync effect strips the
  bare `group` query param on every non-topology view (reserves it for
  topology grouping mode). Renamed the compare URL param `group` →
  `apiGroup` to match Radar's repo-wide convention. App.tsx never
  touches `apiGroup`, so the value survives navigation into /compare.
  Affected writers: useCompareLauncher, ResourcesView compare-nav,
  CompareViewRoute swap. Reader: CompareViewRoute. Docs updated.
- ResourceCompareView now takes per-side `aLoading` / `bLoading`
  instead of a single `loading` flag. A fast side renders immediately
  while the slow side spins; only both-pending shows the full-pane
  "Loading resources…" placeholder.
- When both sides fail, the error banner now lists both messages
  (`A: <aError> · B: <bError>`) instead of dropping bError on the floor.
Compare-as-text was pushing the action bar to a second row at narrow
drawer widths on kinds with the most actions (Pod, Node, Flux
Kustomization/HelmRelease, Argo Application). Two coordinated changes:

- Compare is now icon-only (matches Delete's pattern at the same
  right-end of the bar; tooltip "Compare to another <kind>" stays).
  Saves ~70px from the row.
- Drawer MIN_WIDTH bumped 400 → 520. The previous 400 left even
  text-light kinds wrapping; 520 fits the widest button row (Pod with
  Terminal + Logs + Port Forward + YAML + Compare + Delete) with
  ~12px of slack.
@nadaverell nadaverell merged commit 057b003 into main May 23, 2026
7 checks passed
@nadaverell nadaverell deleted the feature/compare-resources branch May 23, 2026 15:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant